不限距离4g/5g信号远程遥控小车

您所在的位置:网站首页 gkui远程控制 范围有多远 不限距离4g/5g信号远程遥控小车

不限距离4g/5g信号远程遥控小车

2023-05-28 20:35| 来源: 网络整理| 查看: 265

       

4g/5g不限距离遥控小车(1)

        

4g/5g不限距离遥控小车(2)

        最开始学习编程也是源于一个想法, 无线遥控小车和飞机操作范围都是在几十米, 远的几百米, 再远的几公里, 那能不能把手机放在小车或飞机上, 利用手机的4g/5g信号来接收指令, 这样只要有手机信号的地方, 就可以不限距离的操作, 当时我面临两个非常棘手的问题, 一个是视频图像传输的实时性, 因为我要坐在电脑前实时操作小车和飞机的路线, 那么对视频传输的延时就要求很高; 第二个是当时我并不知道安卓手机可以利用AOA协议来扩展外设, 当年我看的所有安卓视频教程中从来没有一个老师提起过, 导致我这个想法一直没能实现. 非常偶然的一个机会, 我在百度里才发现了安卓手机可以用AOA协议来扩展外设, 后来通过学习又解决了视频传输实时性的问题, 好了, 开始动手搞起来~        

        服务端源码已经上传到gitee了, 喜欢的可以去下载, 记得手抖点个赞哈~

        [email protected]:phoenix3k/client-a_client-b_-server.git

        前一阵修复了clientA和clientB的两处bug, 其一是更新到最新版本的百度地图sdk 7.4版本, 其二是socketio在高版本AndroidSDK中无法通信的bug (为此我将单独写一篇博客记录下来), 目前已经适配了从Android12到Android4.4的兼容.

        后续会开放ClientA和ClientB的源代码

        小车改装升级为四驱越野, 2百多的车还是有点心疼的, 同时还做了一个简单的充电器, 直接上图

        先上两张图, 小车已经可以正常跑起来了~

        

再来说说我都消耗了哪些东西:

        1. 两部旧的安卓手机, 一个是乐视2安卓6.0, 一个是小米5s安卓7.0

        2. x宝上淘的一块FT311D开发模块

        3. x宝上淘的两块4000mAh, 3.7v可充电锂电池

        4. x宝上淘的一块pwm电机模块, 用于驱动马达

        5. x宝上淘的一块移动电源主板升压充电模块, 锂电池3v 3.7v升5v 1A升压板

        6. x宝上淘的一块DC-DC升压模块, 2A升压板, 宽压输入2/24v升5/9/12/28v 可调

        7. 拆掉了儿子一辆无线遥控小车

        8. 一台阿里云服务器, 配置8g内存, 2核CPU, 5m带宽

        9. 一台笔记本电脑或台式机

需要做的工作和目前实现了哪些功能:

        1. 乐视2手机用于和FT311D版相连, 用于接收控制指令, 以及传输视频图像, 经纬度坐标, 手机电量信息等, 出于起名困难, 我称这个手机为clientB.

        2. 小米5s手机用于发出控制指令, 接收经纬度坐标, 并在百度地图中显示, 接收clientB电量信息, 发出打开或关闭clientB的摄像头, 接收clientB的连接状态, 出于起名困难, 我称这个手机为clientA.

        3. 服务器上部署了srs服务, 用于低延时视频传输, 这部分我已经单独发了一篇帖子, 感兴趣的小伙伴可以去看看, 同时部署了服务端程序, 这部分的代码我并没有用java, 而是用来nodejs来实现, 功能相对简单, 只是负责转发clientB和clientA的指令和状态信息, 控制指令和消息的转发安卓端和服务端都是用了socketIO.

        需要懂一些原生安卓开发的知识, 当然如果小伙伴不想用nodejs来写后台服务, 完全可以替换成java, 了解一些Linux的知识最好, 方便应用的部署, 懂一些srs服务的部署, 这个可以参考gitee的官方wiki, 需要去看FT311D的文档, 了解如何使用FT311D开发版以及嵌入到自己的安卓项目中, 这部分资源我也上传到了我的CSDN资源中, 喜欢的小伙伴可以下载.

        先上nodejs服务器端的代码, 比较简单, 只是做了自定义命令的转发.

        app.js

var app = require('express')(); var server = require('http').Server(app); var io = require('socket.io')(server); server.listen(6547); // 监听自己的服务端口 app.use('/api', require('./routers/api')); io.on('connection', function(socket) { console.log("来了一个人 " + socket.id + "-" + socket.request.connection.remoteAddress); // 监听客户端离线事件 socket.on('disconnect', function() { console.log("对方下线了"); socket.broadcast.emit('cmd', '对方下线了'); }); // 监听clientA和clientB发送的cmd命令 socket.on('cmd', function(msg) { if (msg == "B" || msg == "A") { socket.emit('cmd', "通信成功"); } if (msg == "A:up") { console.log("up"); socket.broadcast.emit('cmd', 'up'); } if (msg == "A:back") { console.log("back"); socket.broadcast.emit('cmd', 'back'); } if (msg == "A:left") { console.log("left"); socket.broadcast.emit('cmd', 'left'); } if (msg == "A:right") { console.log("right"); socket.broadcast.emit('cmd', 'right'); } if (msg == "A:stop") { console.log("stop"); socket.broadcast.emit('cmd', 'stop'); } if (msg.indexOf("speed:") != -1) { socket.broadcast.emit('speed', msg.slice(6)); console.log(msg.slice(6)); } if (msg.indexOf("period:") != -1) { socket.broadcast.emit('period', msg.slice(7)); console.log(msg.slice(7)); } if (msg == "push") { // 发送推流命令到clientB socket.broadcast.emit('cmd', 'push'); console.log('push'); } if (msg == "close") { // 发送关闭推流命令到clientB socket.broadcast.emit('cmd', 'close'); console.log('close'); } }); socket.on('location', function(msg) { // 转发clientB位置信息 var LatLon = msg.split(","); socket.broadcast.emit('location', LatLon[0], LatLon[1]); }); socket.on('elect', function(msg) { // 转发clientB电量信息 socket.broadcast.emit('elect', msg); onsole.log(msg); }); });

        api.js 负责推流和拉流简单的鉴权验证

var express = require("express"); var app = express(); var router = express.Router(); var formidable = require("formidable"); // 推流鉴权 router.post('/publish', function(req, res, next) { console.log("--------推流鉴权--------"); var form = new formidable.IncomingForm(); form.encoding = 'utf-8'; form.parse(req, function(err, fields, files) { if (fields.stream == '147') { // 这里的流名称请参考srs的wiki res.statusCode = 200; res.write("0"); res.end(); } else { res.write("1"); res.end(); } // console.log(fields); }); }); // 拉流鉴权 router.post('/play', function(req, res, next) { console.log("--------拉流鉴权--------"); var form = new formidable.IncomingForm(); form.encoding = 'utf-8'; form.parse(req, function(err, fields, files) { if (fields.stream == '147') { // 这里的流名称请参考srs的wiki res.statusCode = 200; res.write("0"); res.end(); } else { res.write("1"); res.end(); } // console.log(fields); }); }); // 声网分发token const Role = { // DEPRECATED. Role::ATTENDEE has the same privileges as Role.PUBLISHER. ATTENDEE: 0, // RECOMMENDED. Use this role for a voice/video call or a live broadcast, if your scenario does not require authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in). PUBLISHER: 1, /* Only use this role if your scenario require authentication for [Hosting-in](https://docs.agora.io/en/Agora%20Platform/terms?platform=All%20Platforms#hosting-in). * @note In order for this role to take effect, please contact our support team to enable authentication for Hosting-in for you. Otherwise, Role.SUBSCRIBER still has the same privileges as Role.PUBLISHER. */ SUBSCRIBER: 2, // DEPRECATED. Role.ADMIN has the same privileges as Role.PUBLISHER. ADMIN: 101 } class RtcTokenBuilder { static buildTokenWithUid(appID, appCertificate, channelName, uid, role, privilegeExpiredTs) { return this.buildTokenWithAccount(appID, appCertificate, channelName, uid, role, privilegeExpiredTs) } static buildTokenWithAccount(appID, appCertificate, channelName, account, role, privilegeExpiredTs) { this.key = new AccessToken(appID, appCertificate, channelName, account) this.key.addPriviledge(Priviledges.kJoinChannel, privilegeExpiredTs) if (role == Role.ATTENDEE || role == Role.PUBLISHER || role == Role.ADMIN) { this.key.addPriviledge(Priviledges.kPublishAudioStream, privilegeExpiredTs) this.key.addPriviledge(Priviledges.kPublishVideoStream, privilegeExpiredTs) this.key.addPriviledge(Priviledges.kPublishDataStream, privilegeExpiredTs) } return this.key.build(); } } module.exports = router; module.exports.Role = Role; module.exports.RtcTokenBuilder = RtcTokenBuilder;

        最近用闲散时间改进了服务端和Android端的代码, 之前只能满足一个clientA和一个clientB进行通信, 无法实现多组clientA和clientB通信, 现在把Android端和nodejs的代码加入了"房间"的概念, 每个"房间"有且只有一组clientA和clientB, 而且只能通过clientA来创建房间, clientB来加入房间, 就可以有多个"房间"同时工作, 而各个房间里的clientA发出的指令只对该"房间"的clientB起作用, 而房间之间互不干扰, 先上改进后的服务端代码, 本次服务端代码只改了app.js.

const app = require('express')(); const server = require('http').Server(app); const io = require('socket.io')(server); const RtcTokenBuilder = require('./routers/api').RtcTokenBuilder; const RtcRole = require('./routers/api').Role; // 生成声网token所需参数 const appID = 'xxxx'; // 此处请填写自己在声网创建项目的appid const appCertificate = 'xxxx'; // 此处请填写自己在声网创建项目的app证书 const uid = 0; // const account = "2882341273"; const role = RtcRole.PUBLISHER; const expirationTimeInSeconds = 3600; app.use('/api', require('./routers/api')); var rooms = []; // rooms数组中又存储每一个room对象 var tmp = []; var channelName = ''; var token; io.on("connection", (socket) => { console.log("来了一个人 " + socket.id + "-" + socket.request.connection.remoteAddress); // 接到一个客户端连接后, 判断客户端类型, 加入的房间号, 房间密码, 以及房间里的人数, 每个房间只容纳两个客户端, 一个clientA, 一个clientB socket.on("info", (roomNum, roomPwd, socketType) => { if (rooms.length == 0 && socketType == 'clientA') { // 只允许clientA创建房间 var room = {}; room.roomNum = roomNum; room.roomPwd = roomPwd; room.socketType = socketType; room.socketID = socket.id; rooms.push(room); socket.emit("message", 1); // 用户加入房间 socket.join(roomNum); } else if (rooms.length == 0 && socketType == 'clientB') { // clientB不允许创建房间 socket.emit("message", 0); } else { if (rooms.length != 0) { if (socketType == 'clientA') { for (let i = 0; i < rooms.length; i++) { if (rooms[i].roomNum == roomNum && rooms[i].socketType == 'clientA') { socket.emit("message", 0); break; } if (rooms[i].roomNum == roomNum && rooms[i].socketType == 'clientB' && i == ( rooms.length - 1)) { var room = {}; room.roomNum = roomNum; room.roomPwd = roomPwd; room.socketType = socketType; room.socketID = socket.id; rooms.push(room); // 用户加入房间 socket.join(roomNum); socket.emit("message", 1); break; } if (rooms[i].roomNum != roomNum && i == (rooms.length - 1)) { var room = {}; room.roomNum = roomNum; room.roomPwd = roomPwd; room.socketType = socketType; room.socketID = socket.id; rooms.push(room); // 用户加入房间 socket.join(roomNum); socket.emit("message", 1); break; } } } if (socketType == 'clientB') { for (let i = 0; i < rooms.length; i++) { if (isContain(rooms, roomNum, 'clientA') && ! isContain(rooms, roomNum, 'clientB') && rooms[i].roomPwd == roomPwd ) { var room = {}; room.roomNum = roomNum; room.roomPwd = roomPwd; room.socketType = socketType; room.socketID = socket.id; rooms.push(room); // 用户加入房间 socket.join(roomNum); socket.emit("message", 1); break; } if (isContain(rooms, roomNum, 'clientA') && ! isContain(rooms, roomNum, 'clientB') && rooms[i].roomPwd != roomPwd ) { socket.emit("message", -1); break; } if (!isContain(rooms, roomNum, 'clientA') && !isContain(rooms, roomNum, 'clientB')) { socket.emit("message", 0); break; } } } } } }); // 监听客户端离线事件 socket.on("disconnect", () => { for (let i = 0; i < rooms.length; i++) { if (rooms[i].socketID == socket.id) { socket.leave(rooms[i].roomNum); rooms.splice(i, 1); } } console.log("对方下线了" + socket.id); }); // 监听clientA和clientB发送的cmd命令 socket.on('cmd', function(msg) { if (msg == "B" || msg == "A") { socket.emit('cmd', "通信成功"); } for (let i = 0; i < rooms.length; i++) { if (rooms[i].socketID == socket.id) { if (msg == "A:up") { console.log("up"); socket.to(rooms[i].roomNum).emit('cmd', 'up'); } if (msg == "A:back") { console.log("back"); socket.to(rooms[i].roomNum).emit('cmd', 'back'); } if (msg == "A:left") { console.log("left"); socket.to(rooms[i].roomNum).emit('cmd', 'left'); } if (msg == "A:right") { console.log("right"); socket.to(rooms[i].roomNum).emit('cmd', 'right'); } if (msg == "A:stop") { console.log("stop"); socket.to(rooms[i].roomNum).emit('cmd', 'stop'); } if (msg.indexOf("speed:") != -1) { socket.to(rooms[i].roomNum).emit('speed', msg.slice(6)); console.log(msg.slice(6)); } if (msg.indexOf("period:") != -1) { socket.to(rooms[i].roomNum).emit('period', msg.slice(7)); console.log(msg.slice(7)); } if (msg == "push") { // 发送推流命令到clientB socket.to(rooms[i].roomNum).emit('cmd', 'push'); console.log('push'); } if (msg == "close") { // 发送关闭推流命令到clientB socket.to(rooms[i].roomNum).emit('cmd', 'close'); console.log('close'); } if (msg == "token") { // 返回给客户端token channelName = rooms[i].roomNum; if (rooms[i].socketType == 'clientB') { io.to(socket.id).emit('token', token); // 把token返回给发送请求的socket端 } else { let currentTimestamp = Math.floor(Date.now() / 1000); let privilegeExpiredTs = currentTimestamp + expirationTimeInSeconds; token = RtcTokenBuilder.buildTokenWithUid(appID, appCertificate, channelName, uid, role, privilegeExpiredTs); io.to(socket.id).emit('token', token); // 把token返回给发送请求的socket端 // socket.emit('cmd', token); } console.log("Token is: " + token); } } } }); socket.on('location', function(msg) { for (let i = 0; i < rooms.length; i++) { if (rooms[i].socketID == socket.id) { var LatLon = msg.split(","); socket.broadcast.emit('location', LatLon[0], LatLon[1]); } } }); socket.on('elect', function(msg) { for (let i = 0; i < rooms.length; i++) { if (rooms[i].socketID == socket.id) { socket.broadcast.emit('elect', msg); console.log(msg); } } }); }); function isContain(rooms, roomNum, type) { for (let i = 0; i < rooms.length; i++) { if (rooms[i].roomNum == roomNum && rooms[i].socketType == type) { return true; } else { continue; } } return false; } server.listen(3764);

        顺便说一下, 服务器端的socketIO版本升级到了最新, 4.2.0, 同时Android端的版本升级到了2.0.1, 这两个版本一定是对应的, 相关版本兼容说明可直接参考官网说明, 否则一定扑街!!!

        这次的改进也在两个Android端加入了语音功能, 引入了第三方的平台---声网, 之所以选择它是因为能快速接入语音功能, 目前已实现了clientA和clientB实时对讲功能, clientB端我默认开启了免提功能, 这样clientA和clientB就能相互喊话啦! 有的小伙伴要问, 为什么你视频功能不接入第三方平台呢, 原因很简单, 传输时效达不到我的要求~

        本次修改优化了声网语音电话的token申请, token的生成需要自己写逻辑, token过期时间为24小时, 这个在声网官网中都有明确的说明, 直接上我服务器生成token的代码, 也是从声网搬砖来的, 声网的token生成有多个语言版本, 由于我的后台服务是nodejs, 所以我选择的是这个版本的代码.

        AccessToken.js 是token生成的核心代码, 直接搬砖到自己服务器上ok了~

var crypto = require('crypto'); var crc32 = require('crc-32'); var UINT32 = require('cuint').UINT32; var version = "006"; var randomInt = Math.floor(Math.random() * 0xFFFFFFFF); const VERSION_LENGTH = 3; const APP_ID_LENGTH = 32; var AccessToken = function(appID, appCertificate, channelName, uid) { let token = this; this.appID = appID; this.appCertificate = appCertificate; this.channelName = channelName; this.messages = {}; this.salt = randomInt; this.ts = Math.floor(new Date() / 1000) + (24 * 3600); if (uid === 0) { this.uid = ""; } else { this.uid = `${uid}`; } this.build = function() { var m = Message({ salt: token.salt, ts: token.ts, messages: token.messages }).pack(); var toSign = Buffer.concat( [Buffer.from(token.appID, 'utf8'), Buffer.from(token.channelName, 'utf8'), Buffer.from(token.uid, 'utf8'), m ]); var signature = encodeHMac(token.appCertificate, toSign); var crc_channel = UINT32(crc32.str(token.channelName)).and(UINT32(0xffffffff)).toNumber(); var crc_uid = UINT32(crc32.str(token.uid)).and(UINT32(0xffffffff)).toNumber(); var content = AccessTokenContent({ signature: signature, crc_channel: crc_channel, crc_uid: crc_uid, m: m }).pack(); return (version + token.appID + content.toString('base64')); } this.addPriviledge = function(priviledge, expireTimestamp) { token.messages[priviledge] = expireTimestamp; }; this.fromString = function(originToken) { try { originVersion = originToken.substr(0, VERSION_LENGTH); if (originVersion != version) { return false; } var originAppID = originToken.substr(VERSION_LENGTH, (VERSION_LENGTH + APP_ID_LENGTH)); var originContent = originToken.substr((VERSION_LENGTH + APP_ID_LENGTH)); var originContentDecodedBuf = Buffer.from(originContent, 'base64'); var content = unPackContent(originContentDecodedBuf); this.signature = content.signature; this.crc_channel_name = content.crc_channel_name; this.crc_uid = content.crc_uid; this.m = content.m; var msgs = unPackMessages(this.m); this.salt = msgs.salt; this.ts = msgs.ts; this.messages = msgs.messages; } catch (err) { console.log(err); return false; } return true; }; }; module.exports.version = version; module.exports.AccessToken = AccessToken; module.exports.priviledges = { kJoinChannel: 1, kPublishAudioStream: 2, kPublishVideoStream: 3, kPublishDataStream: 4, kRtmLogin: 1000 }; var encodeHMac = function(key, message) { return crypto.createHmac('sha256', key).update(message).digest(); }; var ByteBuf = function() { var that = { buffer: Buffer.alloc(1024), position: 0 }; that.buffer.fill(0); that.pack = function() { var out = Buffer.alloc(that.position); that.buffer.copy(out, 0, 0, out.length); return out; }; that.putUint16 = function(v) { that.buffer.writeUInt16LE(v, that.position); that.position += 2; return that; }; that.putUint32 = function(v) { that.buffer.writeUInt32LE(v, that.position); that.position += 4; return that; }; that.putBytes = function(bytes) { that.putUint16(bytes.length); bytes.copy(that.buffer, that.position); that.position += bytes.length; return that; }; that.putString = function(str) { return that.putBytes(Buffer.from(str)); }; that.putTreeMap = function(map) { if (!map) { that.putUint16(0); return that; } that.putUint16(Object.keys(map).length); for (var key in map) { that.putUint16(key); that.putString(map[key]); } return that; }; that.putTreeMapUInt32 = function(map) { if (!map) { that.putUint16(0); return that; } that.putUint16(Object.keys(map).length); for (var key in map) { that.putUint16(key); that.putUint32(map[key]); } return that; }; return that; } var ReadByteBuf = function(bytes) { var that = { buffer: bytes, position: 0 }; that.getUint16 = function() { var ret = that.buffer.readUInt16LE(that.position); that.position += 2; return ret; }; that.getUint32 = function() { var ret = that.buffer.readUInt32LE(that.position); that.position += 4; return ret; }; that.getString = function() { var len = that.getUint16(); var out = Buffer.alloc(len); that.buffer.copy(out, 0, that.position, (that.position + len)); that.position += len; return out; }; that.getTreeMapUInt32 = function() { var map = {}; var len = that.getUint16(); for (var i = 0; i < len; i++) { var key = that.getUint16(); var value = that.getUint32(); map[key] = value; } return map; }; return that; } var AccessTokenContent = function(options) { options.pack = function() { var out = new ByteBuf(); return out.putString(options.signature) .putUint32(options.crc_channel) .putUint32(options.crc_uid) .putString(options.m).pack(); } return options; } var Message = function(options) { options.pack = function() { var out = new ByteBuf(); var val = out .putUint32(options.salt) .putUint32(options.ts) .putTreeMapUInt32(options.messages).pack(); return val; } return options; } var unPackContent = function(bytes) { var readbuf = new ReadByteBuf(bytes); return AccessTokenContent({ signature: readbuf.getString(), crc_channel_name: readbuf.getUint32(), crc_uid: readbuf.getUint32(), m: readbuf.getString() }); } var unPackMessages = function(bytes) { var readbuf = new ReadByteBuf(bytes); return Message({ salt: readbuf.getUint32(), ts: readbuf.getUint32(), messages: readbuf.getTreeMapUInt32() }); }



【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3